昨天已經把散步時間、距離與偏好,轉成 Google Routes 能理解的起點、終點和 waypoint。
但只算出一條能走的道路還不夠。
假設使用者設定:
Google Routes 可能算出 900 公尺,也可能超過四公里。這些路線都能走,卻不一定符合使用者真正想要的散步體驗。
因此不能只建立一組 waypoint,而是要先產生多組候選,再從中挑出最符合條件的一條。
目前候選分成兩種:
為什麼一定要兩種?
如果完全依賴 Places,在住宅區、郊區或使用者沒有設定偏好時,可能根本沒有足夠的候選,因此仍需要幾何候選當作保底。
昨天介紹過如何取得 waypoint,今天重點不是怎麼搜尋 Places,而是怎麼從搜尋結果挑出適合建立候選路線的 waypoint。
如果使用者選擇公園、咖啡店等偏好,就先透過 Google Places 搜尋起點附近符合條件的地點,再將合適的結果轉成 waypoint:
使用者選擇「公園」
↓
搜尋起點附近的公園
↓
取得地點座標
↓
建立 Places 候選
但搜尋到的第一個地點不一定適合。
如果使用者想走3公里並回到起點,而某座公園距離起點已經有3公里,光是來回就可能超過6公里。
因此,在回到起點的情況下,會先估算 waypoint 與起點的理想距離:
// 預期 waypoint 距離 = 目標路線距離 ÷ 2
const expectedPlaceDistance = targetDistance / (request.returnToStart ? 2 : 1);
如果目標是三公里環線,就會優先考慮距離起點約 1.5 公里的地點。
接著計算每個 Places 結果與起點的直線距離,再按照與預期距離的差距排序:
const relevantPlaces = places
.map(place => ({
place,
distance: approximateDistance(
request.start,
place.location,
),
}))
.sort(
(a, b) =>
Math.abs(a.distance - expectedPlaceDistance) -
Math.abs(b.distance - expectedPlaceDistance),
)
.slice(0, 2);
例如預期距離是 1500 公尺:
公園 A:300 公尺,差距 1200 公尺
公園 B:1400 公尺,差距 100 公尺
公園 C:2500 公尺,差距 1000 公尺
這時會優先保留公園 B,而不是選離起點最近的公園 A。
目前只取前兩個結果,避免產生太多候選,也能控制 Google Routes 的請求次數。
最後再把保留下來的地點轉成 waypoint,建立 placeCandidates。
這裡比較的仍是直線距離,實際道路可能受到河流、圍牆、街道方向與人行道影響,最後仍要以 Google Routes 回傳的結果為準。
Places 不一定每次都有結果,使用者也可能沒有設定地點偏好。
因此,我另外根據起點、目標距離與方向,自行建立幾組幾何 waypoint。
以回到起點的路線為例,每組候選包含兩個轉折點:
起點
↓
轉折點一
↓
轉折點二
↓
起點
兩個 waypoint 已經足以讓路線產生轉折。加入太多 waypoint,反而可能讓路線為了經過指定座標而繞遠路。
const geometricCandidates =
CANDIDATE_BEARINGS.map((bearing, index) => {
const side = targetDistance / 3;
const first = destinationPoint(
request.start,
side,
bearing,
);
const second = destinationPoint(
request.start,
side,
bearing + 60,
);
return [
createWaypoint(first, index, 0),
createWaypoint(second, index, 1),
];
});
每個方向會建立一組候選,第二個 waypoint 再偏轉 60 度,避免整條路線只沿著同一個方向前進。
最後把兩種候選合併:
return [
...placeCandidates,
...geometricCandidates,
];
即使 Places 沒有找到合適地點,仍然可以使用幾何候選繼續規劃。

候選建立完成後,每一組 waypoint 都會分別交給 Google Routes 計算。
但幾何座標可能落在無法步行抵達的位置,也可能因為道路配置而找不到合理路線。
假設結果是:
候選 A:失敗
候選 B:成功
候選 C:成功
候選 D:失敗
B 和 C 仍然可以繼續使用,不需要因為其中一組失敗,就中止整次推薦。
因此,這裡使用 Promise.allSettled():
const results = await Promise.allSettled(
candidates.map(candidate =>
computeRoute(candidate),
),
);
const routes = results.flatMap(result =>
result.status === 'fulfilled'
? result.value
: [],
);
最後只讓成功的路線進入評分:
候選 A:失敗 ── 排除
候選 B:成功 ─┐
├→ 進入評分
候選 C:成功 ─┘
候選 D:失敗 ── 排除
經過前面的流程後,通常還會留下多條可以使用的路線。
例如:
| 候選 | 距離 | 時間 | 經過公園 | 重複路段 |
|---|---|---|---|---|
| A | 2.2 km | 30 分鐘 | ✓ | 10% |
| B | 3.0 km | 42 分鐘 | ✗ | 5% |
| C | 2.7 km | 35 分鐘 | ✓ | 8% |
A 符合偏好,但距離偏短、B 距離最接近,卻沒有經過公園、C 沒有任何一項特別突出,但整體可能更符合需求。
Google Routes 只負責規劃道路,不會告訴我哪一條比較適合,因此還需要自己建立一套比較規則。
目前主要會考慮四個因素:
距離和時間都不能直接比較相差幾公尺或幾分鐘。
例如同樣多出 500 公尺:
目標 1 公里,實際 1.5 公里
誤差 50%
目標 10 公里,實際 10.5 公里
誤差 5%
兩者都多走 500 公尺,但對一公里散步來說影響很大,對十公里則小得多。
因此,我改用相對誤差來比較不同長度的散步需求。
相對誤差 = |實際值 - 目標值| ÷ 目標值
另外,如果使用者直接指定公里數,我會提高距離的重要性;如果只指定散步時間,距離只是依平均步行速度換算出的參考值,因此權重就不需要那麼高。
偏好目前不是重新分析整條道路沿途經過哪些 POI,而是直接根據建立候選時使用的 waypoint 判斷。
例如 waypoint 來自公園,而使用者也選擇公園偏好,就代表這條路線一定會經過公園,因此可以直接視為符合偏好。
至於重複路段,我沒有比對道路 ID,而是根據 Polyline 比較相鄰線段的位置與方向,估算整條路線有多少比例屬於原路折返。
雖然這種方法只是近似值,但實作相對簡單,也足以避免大量折返的路線被優先推薦。
最後,把前面幾個因素整理成一個分數,再比較所有候選路線。
分數越低代表越符合使用者需求:
const PREFERENCE_WEIGHT = 0.08;
const REPETITION_WEIGHT = 1.5;
// 距離誤差
const distanceScore =
distanceWeight *
Math.abs(route.distanceMeters - targetDistance) /
Math.max(targetDistance, 1);
// 時間誤差
const durationScore =
Math.abs(route.durationSeconds - targetDuration) /
Math.max(targetDuration, 1);
// 符合多少偏好
const preferenceBonus =
route.preferenceMatches.length *
PREFERENCE_WEIGHT;
// 重複路段懲罰
const repetitionPenalty =
preferLessRepetition
? route.repetitionRatio *
REPETITION_WEIGHT
: 0;
// score = 距離誤差 + 時間誤差 - 偏好獎勵 + 重複懲罰
const score = distanceScore + durationScore - preferenceBonus + repetitionPenalty;
距離和時間與目標差得越多,分數就會越高;符合的偏好越多,則會降低分數;如果有大量重複路段,就再加上額外的懲罰分數。
這個計算方式與權重只是暫時的,後續還可以依照實際測試逐步調整。
最後比較所有候選的分數,分數最低的一條就會作為最終推薦路線回傳給 App。
到這裡,推薦路線的產生流程就完成了:
Places 候選 ─┐
├→ Google Routes
幾何候選 ────┘
↓
排除失敗路線
↓
計算候選分數
↓
選出最佳候選
↓
回傳 App
接下來就可以把推薦結果顯示在地圖上,讓使用者真正開始沿著推薦路線散步。